Skip to content

Fix phpstan/phpstan#13526: False positive with array_key_exists and union types#5103

Closed
phpstan-bot wants to merge 135 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-9jd3jcc
Closed

Fix phpstan/phpstan#13526: False positive with array_key_exists and union types#5103
phpstan-bot wants to merge 135 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-9jd3jcc

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

Fixes a false positive where array_key_exists($item, $map) with a union type array ($map = $a ? $map1 : $map2) still reported "Offset ... might not exist" when accessing $map[$item] inside the truthy branch.

Changes

  • Modified src/Analyser/MutatingScope.php line 3683: Added && $this->hasExpressionType($expr)->yes() to the performance optimization condition in addTypeToExpression()
  • Added regression test tests/PHPStan/Rules/Arrays/data/bug-13526.php and test method testBug13526 in NonexistentOffsetInArrayDimFetchRuleTest

Root cause

When array_key_exists($item, $map) is processed as a truthy condition, the ArrayKeyExistsFunctionTypeSpecifyingExtension creates a SpecifiedTypes that includes a sure type for $map[$item] with the correct value type (1|2).

During filterBySpecifiedTypes, this sure type is applied via addTypeToExpression(). However, addTypeToExpression() has a performance optimization that returns early (without calling specifyExpressionType()) when the intersected type equals the already-computed type and is a constant scalar value. The problem is that for ArrayDimFetch expressions, the type is computed on-the-fly via getTypeFromArrayDimFetch() without being stored in expressionTypes. So the optimization returned early, and the expression type was never stored.

Later, NonexistentOffsetInArrayDimFetchRule checks $scope->hasExpressionType($node)->yes() to determine if the dim fetch was explicitly narrowed (e.g., by array_key_exists). Since the expression type was never stored, this returned no, and the rule proceeded to report the false positive.

The fix adds $this->hasExpressionType($expr)->yes() to the optimization condition, ensuring the early return only happens when the expression type is already stored. This preserves the optimization for the common case (variables and already-stored expressions) while fixing the issue for computed-but-not-stored expressions like ArrayDimFetch.

Test

Added tests/PHPStan/Rules/Arrays/data/bug-13526.php with a reproduction case: two constant arrays selected via a ternary, followed by array_key_exists + access. The test expects no errors, confirming the false positive is fixed.

Fixes phpstan/phpstan#13526

ondrejmirtes and others added 30 commits February 13, 2026 10:49
Automatically detects undocumented parameters in conf/parametersSchema.neon
and creates a draft PR on phpstan/phpstan with documentation updates.
Triggers on push to 2.2.x when the schema changes, or manually.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use github.event.before for push diffs to handle multi-commit pushes.
Add explicit else branch for manual dispatch to check entire schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These parameters exist purely to be toggled by rule levels
and are not configured by users directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The safe-output patch was trying to create the file as new, but it
already exists in phpstan/phpstan. Add a pre-step that fetches and
commits the file so the agent's edits produce a modification patch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Instead of using safe-outputs create-pull-request (which generates
patches that fail on cross-repo applies), checkout phpstan/phpstan
to __phpstan-website/ subdirectory, edit config-reference.md in place,
and push branch + create PR via bash/gh CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The gh-aw framework runs git commands in the workspace root expecting
a git repository. Without checking out phpstan-src first, the
"Configure Git credentials" step fails with "not a git repository".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The parametersSchema.neon has nested structure() blocks like
exceptions.check.* and cache.*. The agent was only looking at
top-level parameters and missing nested ones like throwTypeCovariance
and tooWideImplicitThrowType.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@VincentLanglet
Copy link
Contributor

Related to #3933 cc @staabm

@staabm staabm changed the title Fix #13526: False positive with array_key_exists and union types Fix phpstan/phpstan#13526: False positive with array_key_exists and union types Mar 1, 2026
@staabm staabm force-pushed the create-pull-request/patch-9jd3jcc branch from 6f5f9cb to 76da17b Compare March 1, 2026 14:28
- name: "Checkout"
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: 2.1.x

Check failure

Code scanning / octoscan

Use of 'actions/checkout' with a custom ref. Error

Use of 'actions/checkout' with a custom ref.
ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" \
--repo phpstan/phpstan \
--json title,body,url)

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
--repo phpstan/phpstan \
--json title,body,url)

TITLE=$(echo "$ISSUE_JSON" | jq -r '.title')

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
run: |
git config user.name "phpstan-bot"

Check failure

Code scanning / octoscan

Expression injection, "steps..outputs." is potentially untrusted. Error

Expression injection, "steps.**.outputs.**" is potentially untrusted.
PROMPT_EOF
)"

- name: "Read Claude's summary"

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
cat /tmp/commit-message.txt
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"
else

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"
else
echo "commit_message=Fix #$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
echo "### Selected issue: #$NUMBER - $TITLE" >> "$GITHUB_STEP_SUMMARY"
done
echo "matrix=$(echo "$SELECTED" | jq -c '.')" >> "$GITHUB_OUTPUT"

Check failure

Code scanning / octoscan

Write to "$GITHUB_OUTPUT" in a bash script. Error

Write to "$GITHUB_OUTPUT" in a bash script.
fail-fast: false
matrix:
issue: ${{ fromJson(needs.pick-issues.outputs.matrix) }}
uses: ./.github/workflows/claude-fix-issue.yml

Check failure

Code scanning / octoscan

Use of local workflow "./.github/workflows/claude-fix-issue.yml" Error

Use of local workflow "./.github/workflows/claude-fix-issue.yml"
path: phpstan-dist
token: ${{ secrets.PHPSTAN_BOT_TOKEN }}
ref: 2.1.x
ref: 2.2.x

Check failure

Code scanning / octoscan

Use of 'actions/checkout' with a custom ref. Error

Use of 'actions/checkout' with a custom ref.
You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool.

Your task is to fix the following GitHub issue from the phpstan/phpstan repository:
Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }}

Check failure

Code scanning / zizmor

code injection via template expansion Error

code injection via template expansion
## Test
Describe the regression test that was added.

Fixes phpstan/phpstan#${{ inputs.issue-number }}

Check failure

Code scanning / zizmor

code injection via template expansion Error

code injection via template expansion
fail-fast: false
matrix:
issue: ${{ fromJson(needs.pick-issues.outputs.matrix) }}
uses: ./.github/workflows/claude-fix-issue.yml

Check warning

Code scanning / zizmor

secrets unconditionally inherited by called workflow Warning

secrets unconditionally inherited by called workflow

permissions:
contents: read
issues: read

Check warning

Code scanning / zizmor

permissions without explanatory comments Warning

permissions without explanatory comments
- name: Trigger Claude Random Easy Fixes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run claude-random-easy-fixes.yml -f issue_count=5 --repo ${{ github.repository }}

Check warning

Code scanning / zizmor

code injection via template expansion Warning

code injection via template expansion
timeout-minutes: 60
permissions:
contents: read
issues: read

Check warning

Code scanning / zizmor

permissions without explanatory comments Warning

permissions without explanatory comments
Comment on lines +1 to +180
name: "Claude Fix Issue"

on:
workflow_dispatch:
inputs:
issue-number:
description: "Issue number from phpstan/phpstan repository"
required: true
type: string
workflow_call:
inputs:
issue-number:
description: "Issue number from phpstan/phpstan repository"
required: true
type: string

permissions:
contents: read

jobs:
fix:
name: "Fix #${{ inputs.issue-number }}"
runs-on: "ubuntu-latest"
timeout-minutes: 60
permissions:
contents: read
issues: read
pull-requests: write

steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit

- name: "Checkout"
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
ref: 2.1.x
repository: phpstan/phpstan-src
fetch-depth: 0

- name: "Install PHP"
uses: "shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1" # v2
with:
coverage: "none"
php-version: "8.4"
ini-file: development
extensions: mbstring

- uses: "ramsey/composer-install@3cf229dc2919194e9e36783941438d17239e8520" # v3

- name: "Install Claude Code"
run: npm install -g @anthropic-ai/claude-code

- name: "Fetch issue details"
id: issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ inputs.issue-number }}
run: |
ISSUE_JSON=$(gh issue view "$ISSUE_NUMBER" \
--repo phpstan/phpstan \
--json title,body,url)

TITLE=$(echo "$ISSUE_JSON" | jq -r '.title')
URL=$(echo "$ISSUE_JSON" | jq -r '.url')
echo "title=$TITLE" >> "$GITHUB_OUTPUT"
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "$ISSUE_JSON" | jq -r '.body' > /tmp/issue-body.txt

- name: "Run Claude Code"
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN: ${{ secrets.PHPSTAN_BOT_FORK_TOKEN }}
run: |
git config user.name "phpstan-bot"
git config user.email "ondrej+phpstanbot@mirtes.cz"

claude --model claude-opus-4-6 \
--dangerously-skip-permissions \
-p "$(cat << 'PROMPT_EOF'
You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool.

Your task is to fix the following GitHub issue from the phpstan/phpstan repository:
Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }}
URL: ${{ steps.issue.outputs.url }}

Issue body is in the file /tmp/issue-body.txt — read it before proceeding.

## Step 1: Write a regression test

Read .claude/skills/regression-test/SKILL.md for detailed guidance on writing regression tests for PHPStan bugs.

The issue body is already provided above — start from Step 2 of the skill (deciding test type). For Step 1 (gathering context), you only need to fetch the playground samples from any playground links found in the issue body.

Skip Steps 5-6 of the skill (reverting fix and committing) — those are not needed here.

The regression test should fail without the fix — verify this by running it before implementing the fix.

## Step 2: Fix the bug

Implement the fix in the source code under src/. Common areas to look:
- src/Analyser/NodeScopeResolver.php - AST traversal and scope management
- src/Analyser/MutatingScope.php - Type tracking
- src/Analyser/TypeSpecifier.php - Type narrowing from conditions
- src/Type/ - Type system implementations
- src/Rules/ - Rule implementations
- src/Reflection/ - Reflection layer

Read CLAUDE.md for important guidelines about the codebase architecture and common patterns.

## Step 3: Verify the fix

1. Run the regression test to confirm it passes now
2. Run the full test suite: make tests
3. Run PHPStan self-analysis: make phpstan
4. Fix any failures that come up
5. Run make cs-fix to fix any coding standard violations
6. Run make name-collision and fix violations - add different tests in unique namespaces. If the function and class declarations are exactly the same, you can reuse them across files instead of duplicating them.

Do not create a branch, push, or create a PR - this will be handled automatically.

## Step 4: Write a summary

After completing the fix, write two files:

1. /tmp/commit-message.txt - A concise commit message (first line: short summary under 72 chars, then a blank line, then a few bullet points describing key changes). Example:
Fix array_key_exists narrowing for template types

- Added handling for TemplateType in TypeSpecifier when processing array_key_exists
- New regression test in tests/PHPStan/Analyser/nsrt/bug-12345.php
- The root cause was that TypeSpecifier did not unwrap template bounds before narrowing

2. /tmp/pr-description.md - A pull request description in this format:
## Summary
Brief description of what the issue was about and what the fix does.

## Changes
- Bullet points of specific code changes made
- Reference file paths where changes were made

## Root cause
Explain why the bug happened and how the fix addresses it.

## Test
Describe the regression test that was added.

Fixes phpstan/phpstan#${{ inputs.issue-number }}

These files are critical - they will be used for the commit message and PR description.
PROMPT_EOF
)"

- name: "Read Claude's summary"
id: claude-summary
env:
ISSUE_NUMBER: ${{ inputs.issue-number }}
run: |
if [ -f /tmp/commit-message.txt ]; then
delimiter="EOF_$(openssl rand -hex 16)"
{
echo "commit_message<<${delimiter}"
cat /tmp/commit-message.txt
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"
else
echo "commit_message=Fix #$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
fi

if [ -f /tmp/pr-description.md ]; then
delimiter="EOF_$(openssl rand -hex 16)"
{
echo "pr_body<<${delimiter}"
cat /tmp/pr-description.md
echo "${delimiter}"
} >> "$GITHUB_OUTPUT"
else
echo "pr_body=Fixes phpstan/phpstan#$ISSUE_NUMBER" >> "$GITHUB_OUTPUT"
fi

Check warning

Code scanning / zizmor

insufficient job-level concurrency limits Warning

insufficient job-level concurrency limits

Your task is to fix the following GitHub issue from the phpstan/phpstan repository:
Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }}
URL: ${{ steps.issue.outputs.url }}

Check notice

Code scanning / zizmor

code injection via template expansion Note

code injection via template expansion
contents: read

jobs:
trigger:

Check notice

Code scanning / zizmor

workflow or action definition without a name Note

workflow or action definition without a name
You are working on phpstan/phpstan-src, the source code of PHPStan - a PHP static analysis tool.

Your task is to fix the following GitHub issue from the phpstan/phpstan repository:
Issue phpstan/phpstan#${{ inputs.issue-number }}: ${{ steps.issue.outputs.title }}

Check notice

Code scanning / zizmor

code injection via template expansion Note

code injection via template expansion
@staabm staabm closed this Mar 1, 2026
@staabm
Copy link
Contributor

staabm commented Mar 1, 2026

accidentally crashed the PR.

creating a new one in #5114

@staabm staabm deleted the create-pull-request/patch-9jd3jcc branch March 1, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants